You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(extensions): register commands on enable after an integration change - #5014
An extension that is disabled while the project changes integration comes back "enabled" with no commands for the new agent. docs/reference/extensions.md describes disable as temporary ("Re-enable with enable"), but here re-enabling silently restores nothing:
$ specify init p --integration claude --ignore-agent-tools --non-interactive && cd p
$ specify extension add --dev ../jira # one command: speckit.jira.sync-issues
$ specify extension disable jira
$ specify integration switch gemini
$ specify extension enable jira
✓ Extension 'Jira' enabled
# .specify/extensions/.registry -> jira: enabled=True, registered_commands={}
# ls .gemini/commands | grep jira -> nothing
integration install gemini followed by integration use gemini gives the same result. The only ways back are selecting the integration again or reinstalling the extension.
Root cause:
integration switch and integration use register only enabled extensions for the agent they activate (register_enabled_extensions_for_agent skips disabled entries).
switch also removes a disabled extension's commands from the previous agent.
extension enable (extensions/command_enable.py) then only flips the registry flag and re-enables hooks. Every integration except generic gets no registration at all.
The fix:
register_enabled_extensions_for_agent() gains a keyword-only extension_ids filter, so a single extension can be registered.
For non-generic agents, enable calls it for this one extension, but only when the registry has no commands recorded for the active agent.
A plain disable/enable cycle, where the commands are still registered, leaves the agent's files untouched.
Agents that take extension commands as skills (e.g. Copilot with --skills) only get missing skill files written.
Overlap: your open PRs #4959 and #4883 also edit extensions/command_enable.py. Neither touches this case, but they will conflict textually with this change. If #4959 lands first, this logic belongs in ExtensionManager.enable(), and the PR can be rebased onto that.
Testing
Tested locally with uv run specify --help
Ran existing tests with uv sync && uv run pytest
Tested with a sample project (if applicable): the reproduction above, on main and on this branch
New tests in tests/specify_cli/extensions/test_command_enable.py, each parametrized over claude and copilot with --skills:
test_enable_registers_commands_for_integration_switched_while_disabled: fails on main (the extension's SKILL.md is missing after enable) and passes with the change
test_enable_leaves_commands_still_registered_for_active_integration: the negative case. A hand-edited skill survives a disable/enable cycle and the registry is unchanged. It passes before and after.
The file gives 5 passed with the change (2 failed on main).
Full suite (macOS, Python 3.13): 12334 passed, 376 skipped. 11 failures in the tests/test_*_python_parity.py files occur identically on unmodified main on this machine under full-suite load; those files pass when run on their own (257 passed, 112 skipped).
uvx ruff@0.15.0 check src tests: clean
AI Disclosure
I did not use AI assistance for this contribution
I did use AI assistance (fill in the disclosure below)
AI disclosure: Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode) opened this PR on behalf of @yxshee. A Claude Code subagent (same model, autonomous) found the bug and wrote the fix and tests. The lead agent independently re-ran the new tests against main and the branch, reproduced the behavior above with the real CLI, reviewed the diff, and wrote this description. No human reviewed the change line by line before it was opened.
`integration switch` and `integration use` register only enabled
extensions for the agent they activate, and `switch` also removes a
disabled extension's commands from the previous agent. For every
integration except `generic`, `extension enable` only flipped the
registry flag and re-enabled hooks, so an extension disabled across a
switch or use came back "enabled" with no commands for the active
agent: no skill or command file and nothing for that agent in
`registered_commands`, until the integration is selected again or the
extension is reinstalled.
When the registry has no commands for the active agent, `enable` now
registers the extension through register_enabled_extensions_for_agent,
which gains an `extension_ids` filter so only that extension is touched.
A plain disable/enable cycle, where the commands are still registered,
leaves the agent's files alone; agents that take extension commands as
skills (Copilot with --skills) only get missing skill files written.
Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decide whether enable must register from the files on disk for the
active agent's layout, not from registered_commands: Copilot --skills
leaves that map empty even when its skills exist, so a plain
disable/enable re-registered and replaced a --dev install's symlinked
skills with regular files. A stale entry could also hide skills that
are actually missing. ExtensionManager.missing_invocation_artifacts()
lists what is absent (a dev symlink counts as present), and enable only
registers when something is missing.
Registration swallows per-extension write failures, and setup errors can
escape it, either way leaving enabled=True with no invocation files and
a retry refused as "already enabled". Re-check the files afterwards and,
on a remaining gap or any exception, restore enabled=False and exit 1
before hooks are re-enabled, as the generic branch already does.
Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Missing-only, mode-aware registration: ExtensionManager.missing_invocation_artifacts() checks the active agent's layout on disk; enable registers only when files are missing, so existing files and dev symlinks are preserved.
Verified outcome: after registering, missing files or any exception restore enabled: false and exit 1, so the next enable can retry.
New tests: dev-symlink preservation (claude, copilot --skills) and rollback-then-retry for claude, copilot --skills and gemini with silent and raising failures. They fail on the previous head and pass now.
The overlap with #4959 noted in the description still applies.
AI disclosure: posted on behalf of @yxshee by Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode); the fix, tests and this reply are AI-generated.
…ndo failures
Round-2 review of the enable repair:
- Check every output registration writes for the active layout, under
the directory registration itself resolves: home-relative (Hermes
~/.hermes/skills) and legacy (OpenCode/Kilo) directories, aliases, and
Copilot's companion .prompt.md, not only the primary name.
- An unusable skills directory now raises, so enable restores the
disabled state instead of reporting success without skills.
- Register only when none of the extension's outputs exist, the state an
integration switch leaves behind. Registration rewrites every command,
so repairing one missing output could discard edits or dev symlinks on
the others; partially present outputs are left untouched.
- Because every output was absent beforehand, a failed attempt removes
the outputs it wrote and restores the registry tracking, so a retry
registers the full set instead of exiting as already complete.
Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addressed all four Copilot findings (commit 629224f):
Outputs are checked under the directory registration resolves (home-relative Hermes, legacy OpenCode/Kilo), including aliases and Copilot's companion .prompt.md.
An unusable skills directory raises, so enable stays disabled and exits 1.
Enable registers only when none of the extension's outputs exist, so surviving files and dev symlinks are never rewritten; partially present outputs are left untouched.
A failed registration removes the outputs it wrote and restores registry tracking, so a retry registers the full set.
Five new tests, each failing on the previous head. Extension and integration suites: 2134 passed. uvx ruff@0.15.0 check src tests: clean.
AI disclosure: posted on behalf of @yxshee by Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode); the fix, tests and this reply are AI-generated.
Return for an empty manifest.commands before constructing CommandRegistrar. Its constructor loads installed integrations and raises if a package was modified or lacks a local trust decision (agents.py:53–65, integrations/installer.py:825–833). The new enable path therefore rejects hook-only extensions even though they have no invocation files to register; previously the event-refresh failure was only a warning. Match the early return in _register_commands_for_active_agent() and add a hook-only regression with registrar setup failure.
AI disclosure: GitHub Copilot (model/settings unknown, autonomous), for the requesting reviewer; generated this feedback from targeted source inspection.
Review round 3, posted on behalf of @yxshee. No code changes in this round; here is why.
The two open Copilot findings share one root cause:
Registration failures masked when outputs exist (r4315795015): a write that creates or truncates a file before raising passes enable's on-disk check.
Rollback can't restore files retired from the previous layout (r4315795095): a commands→skills migration deletes the old agent/prompt files during registration, before enable can validate anything.
register_enabled_extensions_for_agent() deliberately isolates per-extension failures and swallows them, and it retires previous-layout artifacts in the same pass. From enable, the outcome can only be inferred from the filesystem afterwards. That inference can't detect truncated content, and it can't bring back files the registration pass already removed. A sound fix needs the registration layer itself to report a per-extension outcome, and to defer layout retirement until the replacement outputs are validated. That's a change to the shared registration contract, which #4959 is restructuring (it moves enable/disable into ExtensionManager.enable()).
So rather than add a third layer of checks around a helper that hides its errors, I'd suggest one of these:
Close this PR and fold the original bug (an extension disabled across integration switch/use comes back enabled with no commands) plus Copilot's scenarios into feat(extensions): publish validated hook projections #4959 or a tracking issue.
Which would you prefer?
AI disclosure: written by Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode) on behalf of @yxshee; this comment is AI-generated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
An extension that is disabled while the project changes integration comes back "enabled" with no commands for the new agent.
docs/reference/extensions.mddescribes disable as temporary ("Re-enable withenable"), but here re-enabling silently restores nothing:integration install geminifollowed byintegration use geminigives the same result. The only ways back are selecting the integration again or reinstalling the extension.Root cause:
integration switchandintegration useregister only enabled extensions for the agent they activate (register_enabled_extensions_for_agentskips disabled entries).switchalso removes a disabled extension's commands from the previous agent.extension enable(extensions/command_enable.py) then only flips the registry flag and re-enables hooks. Every integration exceptgenericgets no registration at all.The fix:
register_enabled_extensions_for_agent()gains a keyword-onlyextension_idsfilter, so a single extension can be registered.genericagents,enablecalls it for this one extension, but only when the registry has no commands recorded for the active agent.--skills) only get missing skill files written.With the change, the same steps end with:
Overlap: your open PRs #4959 and #4883 also edit
extensions/command_enable.py. Neither touches this case, but they will conflict textually with this change. If #4959 lands first, this logic belongs inExtensionManager.enable(), and the PR can be rebased onto that.Testing
uv run specify --helpuv sync && uv run pytestmainand on this branchNew tests in
tests/specify_cli/extensions/test_command_enable.py, each parametrized overclaudeandcopilotwith--skills:test_enable_registers_commands_for_integration_switched_while_disabled: fails onmain(the extension'sSKILL.mdis missing after enable) and passes with the changetest_enable_leaves_commands_still_registered_for_active_integration: the negative case. A hand-edited skill survives a disable/enable cycle and the registry is unchanged. It passes before and after.The file gives 5 passed with the change (2 failed on
main).tests/specify_cli/extensions,test_command_switch,test_command_use,test_extensions,test_extension_skills,test_extension_registration,test_integration_generic): 1095 passed.tests/test_*_python_parity.pyfiles occur identically on unmodifiedmainon this machine under full-suite load; those files pass when run on their own (257 passed, 112 skipped).uvx ruff@0.15.0 check src tests: cleanAI Disclosure
AI disclosure: Claude Code (model: Claude Opus 5.5, max reasoning effort, autonomous agent mode) opened this PR on behalf of @yxshee. A Claude Code subagent (same model, autonomous) found the bug and wrote the fix and tests. The lead agent independently re-ran the new tests against
mainand the branch, reproduced the behavior above with the real CLI, reviewed the diff, and wrote this description. No human reviewed the change line by line before it was opened.🤖 Generated with Claude Code